🎖️GitЯра🎖️
Commit 91d2f7660bc7d554e9753dc2814864c7b4239a45
Parents : 5d84472
Author : Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-07-19T12:31:25-07:00
Committer : GitHub <noreply@github.com>
Date : 2026-07-19T14:31:25-05:00
Add firmware update notice (#6309)
Co-authored-by: Benjamin Faershtein <benjaminfaershtein@Benjamins-MacBook-Pro-2.local>
Co-authored-by: James Rich <james.a.rich@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Changes
23 files changed, 838 insertions(+), 20 deletions(-)
Diff
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt
index 0c43ce8052..598e390135 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt
@@ -55,6 +55,10 @@ open class FirmwareReleaseRepositoryImpl(
/** Single-flight guard so concurrent collectors share one network refresh. */
private val refreshMutex = Mutex()
+ /** Serializes target-manifest downloads and preserves successful results for the selected release URL. */
+ private val manifestMutex = Mutex()
+ private val manifestTargetsByUrl = mutableMapOf<String, Set<String>>()
+
/**
* Guards [bundledSnapshot] decode so concurrent collectors decode the bundled JSON at most once per process. The
* apply/skip decision itself is re-evaluated every time against the CURRENT active DB — the active Room database
@@ -75,6 +79,21 @@ open class FirmwareReleaseRepositoryImpl(
override val nightlyRelease: Flow<FirmwareRelease?> = getLatestFirmware(FirmwareReleaseType.NIGHTLY)
+ override suspend fun getManifestTargets(release: FirmwareRelease): Set<String>? {
+ val manifestUrl = release.zipUrl.takeIf { it.isNotBlank() } ?: return null
+ return manifestMutex.withLock {
+ manifestTargetsByUrl[manifestUrl]
+ ?: safeCatching { remoteDataSource.getFirmwareReleaseManifest(manifestUrl) }
+ .onFailure { error -> Logger.w(error) { "FirmwareReleaseRepository: manifest fetch failed" } }
+ .getOrNull()
+ ?.targets
+ ?.map { target -> target.board.trim() }
+ ?.filter(String::isNotBlank)
+ ?.toSet()
+ ?.also { targets -> manifestTargetsByUrl[manifestUrl] = targets }
+ }
+ }
+
private fun getLatestFirmware(releaseType: FirmwareReleaseType): Flow<FirmwareRelease?> = staleWhileRevalidateFlow(
loadFromCache = {
ensureSeeded()
diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImplTest.kt
index 04de52a0ac..673092a329 100644
--- a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImplTest.kt
+++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImplTest.kt
@@ -25,6 +25,7 @@ import org.meshtastic.core.data.datasource.BundledAssetReader
import org.meshtastic.core.data.datasource.DeviceLinkLocalDataSource
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.EventFirmwareResponse
+import org.meshtastic.core.model.FirmwareReleaseManifest
import org.meshtastic.core.model.NetworkDeviceHardware
import org.meshtastic.core.model.NetworkDeviceLink
import org.meshtastic.core.model.NetworkDeviceLinksResponse
@@ -49,6 +50,8 @@ class DeviceLinkRepositoryImplTest {
override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = error("unused")
+ override suspend fun getFirmwareReleaseManifest(manifestUrl: String): FirmwareReleaseManifest = error("unused")
+
override suspend fun getNightlyFirmware(): NetworkFirmwareNightly? = error("unused")
override suspend fun getEventFirmware(): EventFirmwareResponse = error("unused")
diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImplTest.kt
index 1f87142446..bdde1a959f 100644
--- a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImplTest.kt
+++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/EventFirmwareRepositoryImplTest.kt
@@ -30,6 +30,7 @@ import org.meshtastic.core.model.EventFirmwareFonts
import org.meshtastic.core.model.EventFirmwareResponse
import org.meshtastic.core.model.EventFirmwareTheme
import org.meshtastic.core.model.EventFirmwareThemeColors
+import org.meshtastic.core.model.FirmwareReleaseManifest
import org.meshtastic.core.model.NetworkDeviceHardware
import org.meshtastic.core.model.NetworkDeviceLinksResponse
import org.meshtastic.core.model.NetworkFirmwareNightly
@@ -56,6 +57,8 @@ class EventFirmwareRepositoryImplTest {
override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = error("unused")
+ override suspend fun getFirmwareReleaseManifest(manifestUrl: String): FirmwareReleaseManifest = error("unused")
+
override suspend fun getNightlyFirmware(): NetworkFirmwareNightly? = error("unused")
override suspend fun getEventFirmware(): EventFirmwareResponse {
diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt
index bd71acc5c8..458a073271 100644
--- a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt
+++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt
@@ -28,6 +28,8 @@ import org.meshtastic.core.database.entity.FirmwareReleaseEntity
import org.meshtastic.core.database.entity.FirmwareReleaseType
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.EventFirmwareResponse
+import org.meshtastic.core.model.FirmwareReleaseManifest
+import org.meshtastic.core.model.FirmwareTarget
import org.meshtastic.core.model.NetworkDeviceHardware
import org.meshtastic.core.model.NetworkDeviceLinksResponse
import org.meshtastic.core.model.NetworkFirmwareNightly
@@ -49,6 +51,8 @@ class FirmwareReleaseRepositoryImplTest {
private class FakeApiService(var response: NetworkFirmwareReleases) : ApiService {
var nightly: NetworkFirmwareNightly? = null
var nightlyUnreachable = false
+ var manifest: FirmwareReleaseManifest? = null
+ var manifestCalls = 0
override suspend fun getDeviceHardware(): List<NetworkDeviceHardware> = error("unused")
@@ -56,6 +60,11 @@ class FirmwareReleaseRepositoryImplTest {
override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = response
+ override suspend fun getFirmwareReleaseManifest(manifestUrl: String): FirmwareReleaseManifest {
+ manifestCalls++
+ return checkNotNull(manifest) { "manifest not configured" }
+ }
+
override suspend fun getNightlyFirmware(): NetworkFirmwareNightly? {
if (nightlyUnreachable) error("nightly index unreachable")
return nightly
@@ -139,6 +148,21 @@ class FirmwareReleaseRepositoryImplTest {
)
}
+ @Test
+ fun `manifest board targets are fetched once and cached by release URL`() = runBlocking {
+ val release =
+ org.meshtastic.core.database.entity.FirmwareRelease(id = "v2.8.0", zipUrl = "https://example.com/manifest")
+ api.manifest =
+ FirmwareReleaseManifest(
+ version = "2.8.0",
+ targets = listOf(FirmwareTarget(board = "tbeam-s3-core", platform = "esp32s3")),
+ )
+
+ assertEquals(setOf("tbeam-s3-core"), repository.getManifestTargets(release))
+ assertEquals(setOf("tbeam-s3-core"), repository.getManifestTargets(release))
+ assertEquals(1, api.manifestCalls)
+ }
+
@Test
fun refreshLeavesLocalRowsUntouched() = runBlocking {
dao.insert(staleRow("v2.7.15.567b8ea", FirmwareReleaseType.STABLE))
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/FirmwareReleaseManifest.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/FirmwareReleaseManifest.kt
new file mode 100644
index 0000000000..5dc0cfab02
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/FirmwareReleaseManifest.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.model
+
+import kotlinx.serialization.Serializable
+
+/** Authoritative target catalogue referenced by a firmware release's `zip_url`. */
+@Serializable
+data class FirmwareReleaseManifest(val version: String = "", val targets: List<FirmwareTarget> = emptyList())
+
+/** One firmware target declared by a [FirmwareReleaseManifest]. */
+@Serializable data class FirmwareTarget(val board: String = "", val platform: String = "")
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/FirmwareUpdateNoticePolicy.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/FirmwareUpdateNoticePolicy.kt
new file mode 100644
index 0000000000..fec33793e7
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/FirmwareUpdateNoticePolicy.kt
@@ -0,0 +1,127 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.model
+
+/** Connection transports that can determine the Android firmware-update destination. */
+enum class FirmwareUpdateTransport {
+ Bluetooth,
+ Serial,
+ Tcp,
+}
+
+/** Where a firmware-update nudge takes the user. */
+enum class FirmwareUpdateDestination {
+ AndroidUpdate,
+ MeshtasticFlasher,
+}
+
+/** The only permitted visual treatment for a firmware update: an informational update nudge. */
+enum class FirmwareUpdateNoticePresentation {
+ Update,
+}
+
+/** Fully validated connected-device firmware update information for platform UI and notifications. */
+data class FirmwareUpdateNotice(
+ val notificationKey: String,
+ val currentVersion: String,
+ val stableVersion: String,
+ val destination: FirmwareUpdateDestination,
+ val presentation: FirmwareUpdateNoticePresentation = FirmwareUpdateNoticePresentation.Update,
+)
+
+/**
+ * Determines whether a connected local device is behind the latest stable firmware and where it can be updated.
+ *
+ * The policy deliberately fails closed: a notice needs a known node identity, hardware target, and valid current and
+ * stable versions. It must never interpret an unknown or malformed version as an update opportunity.
+ */
+object FirmwareUpdateNoticePolicy {
+ private val VERSION_REGEX = Regex("^v?(\\d+)\\.(\\d+)\\.(\\d+)(?:[.+-].*)?$")
+
+ @Suppress("ReturnCount")
+ fun createNotice(
+ nodeIdentity: String?,
+ currentVersion: String?,
+ stableVersion: String?,
+ hardware: DeviceHardware?,
+ transport: FirmwareUpdateTransport,
+ releaseTargets: Set<String>,
+ ): FirmwareUpdateNotice? {
+ val identity = nodeIdentity?.trim().takeIf { !it.isNullOrEmpty() } ?: return null
+ val deviceHardware = hardware ?: return null
+ val target = deviceHardware.platformioTarget.trim().takeIf { it.isNotEmpty() } ?: return null
+ if (releaseTargets.none { it.equals(target, ignoreCase = true) }) return null
+ val current = currentVersion?.let(::parseVersion) ?: return null
+ val stable = stableVersion?.let(::parseVersion) ?: return null
+ if (current >= stable) return null
+
+ return FirmwareUpdateNotice(
+ notificationKey = notificationKey(identity, target, stable.normalized),
+ currentVersion = current.normalized,
+ stableVersion = stable.normalized,
+ destination = destinationFor(deviceHardware, transport),
+ )
+ }
+
+ fun notificationKey(nodeIdentity: String, hardwareTarget: String, stableVersion: String): String {
+ val normalizedStable = parseVersion(stableVersion)?.normalized ?: stableVersion.trim()
+ return "firmware-update-notified:$nodeIdentity:$hardwareTarget:$normalizedStable"
+ }
+
+ fun shouldSchedule(notificationKey: String, alreadyScheduled: Set<String>): Boolean =
+ notificationKey !in alreadyScheduled
+
+ private fun destinationFor(
+ hardware: DeviceHardware,
+ transport: FirmwareUpdateTransport,
+ ): FirmwareUpdateDestination = if (hardware.supportsAndroidUpdate(transport)) {
+ FirmwareUpdateDestination.AndroidUpdate
+ } else {
+ FirmwareUpdateDestination.MeshtasticFlasher
+ }
+
+ private fun DeviceHardware.supportsAndroidUpdate(transport: FirmwareUpdateTransport): Boolean = when (transport) {
+ FirmwareUpdateTransport.Bluetooth -> isEsp32Arc || architecture.contains("nrf", ignoreCase = true)
+
+ FirmwareUpdateTransport.Serial ->
+ !isEsp32Arc &&
+ (
+ architecture.contains("nrf", ignoreCase = true) ||
+ architecture.contains("rp2040", ignoreCase = true)
+ )
+
+ FirmwareUpdateTransport.Tcp -> isEsp32Arc
+ }
+
+ @Suppress("ReturnCount")
+ private fun parseVersion(value: String): ParsedVersion? {
+ val match = VERSION_REGEX.matchEntire(value.trim()) ?: return null
+ val (major, minor, patch) = match.destructured
+ return ParsedVersion(
+ major = major.toIntOrNull() ?: return null,
+ minor = minor.toIntOrNull() ?: return null,
+ patch = patch.toIntOrNull() ?: return null,
+ )
+ }
+
+ private data class ParsedVersion(val major: Int, val minor: Int, val patch: Int) : Comparable<ParsedVersion> {
+ val normalized: String = "$major.$minor.$patch"
+
+ override fun compareTo(other: ParsedVersion): Int =
+ compareValuesBy(this, other, ParsedVersion::major, ParsedVersion::minor, ParsedVersion::patch)
+ }
+}
diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/FirmwareUpdateNoticePolicyTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/FirmwareUpdateNoticePolicyTest.kt
new file mode 100644
index 0000000000..ea415095e0
--- /dev/null
+++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/FirmwareUpdateNoticePolicyTest.kt
@@ -0,0 +1,113 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.model
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class FirmwareUpdateNoticePolicyTest {
+
+ @Test
+ fun `creates an informational OTA update notice for an older connected ESP32 node`() {
+ val notice =
+ FirmwareUpdateNoticePolicy.createNotice(
+ nodeIdentity = "!12345678",
+ currentVersion = "v2.7.15",
+ stableVersion = "v2.7.16",
+ hardware = DeviceHardware(architecture = "esp32-s3", platformioTarget = "t-echo"),
+ transport = FirmwareUpdateTransport.Bluetooth,
+ releaseTargets = setOf("t-echo"),
+ )
+
+ requireNotNull(notice)
+ assertEquals(FirmwareUpdateDestination.AndroidUpdate, notice.destination)
+ assertEquals(FirmwareUpdateNoticePresentation.Update, notice.presentation)
+ assertEquals("2.7.15", notice.currentVersion)
+ assertEquals("2.7.16", notice.stableVersion)
+ }
+
+ @Test
+ fun `does not create a notice for missing malformed current equal or newer versions`() {
+ val hardware = DeviceHardware(architecture = "esp32", platformioTarget = "t-beam")
+
+ listOf(null, "", "unknown", "2.7.16", "2.8.0").forEach { currentVersion ->
+ assertNull(
+ FirmwareUpdateNoticePolicy.createNotice(
+ nodeIdentity = "!12345678",
+ currentVersion = currentVersion,
+ stableVersion = "v2.7.16",
+ hardware = hardware,
+ transport = FirmwareUpdateTransport.Bluetooth,
+ releaseTargets = setOf("t-beam"),
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `selects Meshtastic Flasher when Android cannot update the hardware over the active connection`() {
+ val notice =
+ FirmwareUpdateNoticePolicy.createNotice(
+ nodeIdentity = "!12345678",
+ currentVersion = "2.7.15",
+ stableVersion = "2.7.16",
+ hardware = DeviceHardware(architecture = "nrf52840", platformioTarget = "rak4631"),
+ transport = FirmwareUpdateTransport.Tcp,
+ releaseTargets = setOf("rak4631"),
+ )
+
+ assertEquals(FirmwareUpdateDestination.MeshtasticFlasher, notice?.destination)
+ }
+
+ @Test
+ fun `dedupe key scopes notifications to node hardware target and stable version`() {
+ val first = FirmwareUpdateNoticePolicy.notificationKey("!12345678", "t-beam", "v2.7.16")
+ val sameStableVersion = FirmwareUpdateNoticePolicy.notificationKey("!12345678", "t-beam", "2.7.16")
+ val newerStableVersion = FirmwareUpdateNoticePolicy.notificationKey("!12345678", "t-beam", "2.7.17")
+ val differentTarget = FirmwareUpdateNoticePolicy.notificationKey("!12345678", "t-echo", "2.7.16")
+
+ assertEquals(first, sameStableVersion)
+ assertFalse(first == newerStableVersion)
+ assertFalse(first == differentTarget)
+ }
+
+ @Test
+ fun `does not create a notice when the stable manifest omits the hardware target`() {
+ val notice =
+ FirmwareUpdateNoticePolicy.createNotice(
+ nodeIdentity = "!12345678",
+ currentVersion = "2.7.15",
+ stableVersion = "2.7.16",
+ hardware = DeviceHardware(architecture = "esp32", platformioTarget = "t-beam"),
+ transport = FirmwareUpdateTransport.Bluetooth,
+ releaseTargets = setOf("t-echo"),
+ )
+
+ assertNull(notice)
+ }
+
+ @Test
+ fun `dedupe only suppresses a previously scheduled notification`() {
+ val key = FirmwareUpdateNoticePolicy.notificationKey("!12345678", "t-beam", "2.7.16")
+
+ assertTrue(FirmwareUpdateNoticePolicy.shouldSchedule(key, alreadyScheduled = emptySet()))
+ assertFalse(FirmwareUpdateNoticePolicy.shouldSchedule(key, alreadyScheduled = setOf(key)))
+ }
+}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/FirmwareReleaseRemoteDataSource.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/FirmwareReleaseRemoteDataSource.kt
index b0254e2869..87f8884f8f 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/FirmwareReleaseRemoteDataSource.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/FirmwareReleaseRemoteDataSource.kt
@@ -19,6 +19,7 @@ package org.meshtastic.core.network
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.FirmwareReleaseManifest
import org.meshtastic.core.model.NetworkFirmwareNightly
import org.meshtastic.core.model.NetworkFirmwareReleases
import org.meshtastic.core.network.service.ApiService
@@ -31,6 +32,9 @@ class FirmwareReleaseRemoteDataSource(
suspend fun getFirmwareReleases(): NetworkFirmwareReleases =
withContext(dispatchers.io) { apiService.getFirmwareReleases() }
+ suspend fun getFirmwareReleaseManifest(manifestUrl: String): FirmwareReleaseManifest =
+ withContext(dispatchers.io) { apiService.getFirmwareReleaseManifest(manifestUrl) }
+
/** The nightly preview pointer from meshtastic.github.io, or null when no nightly is published. */
suspend fun getNightlyFirmware(): NetworkFirmwareNightly? =
withContext(dispatchers.io) { apiService.getNightlyFirmware() }
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt
index 8662906b79..8f45510582 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/service/ApiService.kt
@@ -25,6 +25,7 @@ import io.ktor.http.isSuccess
import kotlinx.serialization.json.Json
import org.koin.core.annotation.Single
import org.meshtastic.core.model.EventFirmwareResponse
+import org.meshtastic.core.model.FirmwareReleaseManifest
import org.meshtastic.core.model.NetworkDeviceHardware
import org.meshtastic.core.model.NetworkDeviceLinksResponse
import org.meshtastic.core.model.NetworkFirmwareNightly
@@ -50,6 +51,9 @@ interface ApiService {
/** Fetches the list of available firmware releases from the Meshtastic API. */
suspend fun getFirmwareReleases(): NetworkFirmwareReleases
+ /** Fetches the target manifest referenced by a firmware release's `zip_url`. */
+ suspend fun getFirmwareReleaseManifest(manifestUrl: String): FirmwareReleaseManifest
+
/**
* Fetches the nightly preview build pointer from meshtastic.github.io. Returns null when no nightly is currently
* published (HTTP 404); throws on transport or server errors so callers can distinguish "gone" from "unreachable".
@@ -76,6 +80,9 @@ class ApiServiceImpl(private val client: HttpClient) : ApiService {
override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = client.get("github/firmware/list").body()
+ override suspend fun getFirmwareReleaseManifest(manifestUrl: String): FirmwareReleaseManifest =
+ client.get(manifestUrl).body()
+
override suspend fun getNightlyFirmware(): NetworkFirmwareNightly? {
val response = client.get(NIGHTLY_INDEX_URL)
return when {
diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt
index 4b0acb5113..5d111aba84 100644
--- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt
+++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt
@@ -168,6 +168,30 @@ class UiPrefsImpl(
scope.launch { dataStore.edit { it[KEY_SELECTED_CONNECTION_TRANSPORT] = type.name } }
}
+ override val firmwareUpdateNotificationKeys: StateFlow<Set<String>> =
+ dataStore.data
+ .map { preferences ->
+ preferences[KEY_FIRMWARE_UPDATE_NOTIFICATION_KEYS]?.split('|')?.filter(String::isNotBlank)?.toSet()
+ ?: emptySet()
+ }
+ .stateIn(scope, SharingStarted.Eagerly, emptySet())
+
+ override fun recordFirmwareUpdateNotificationKey(key: String) {
+ scope.launch {
+ dataStore.edit { preferences ->
+ val keys =
+ preferences[KEY_FIRMWARE_UPDATE_NOTIFICATION_KEYS]
+ ?.split('|')
+ ?.filter(String::isNotBlank)
+ ?.toMutableList() ?: mutableListOf()
+ keys.remove(key)
+ keys.add(key)
+ preferences[KEY_FIRMWARE_UPDATE_NOTIFICATION_KEYS] =
+ keys.takeLast(MAX_FIRMWARE_UPDATE_NOTIFICATION_KEYS).joinToString("|")
+ }
+ }
+ }
+
override fun shouldProvideNodeLocation(nodeNum: Int): StateFlow<Boolean> =
cachedFlow(provideNodeLocationFlows, nodeNum) {
val key = booleanPreferencesKey(provideLocationKey(nodeNum))
@@ -290,9 +314,11 @@ class UiPrefsImpl(
val KEY_BLE_AUTO_SCAN = booleanPreferencesKey("ble-auto-scan")
val KEY_NETWORK_AUTO_SCAN = booleanPreferencesKey("network-auto-scan")
val KEY_SELECTED_CONNECTION_TRANSPORT = stringPreferencesKey("selected-connection-transport")
+ val KEY_FIRMWARE_UPDATE_NOTIFICATION_KEYS = stringPreferencesKey("firmware-update-notification-keys")
val KEY_SHOW_BLE_TRANSPORT = booleanPreferencesKey("show-ble-transport")
val KEY_SHOW_NETWORK_TRANSPORT = booleanPreferencesKey("show-network-transport")
val KEY_SHOW_USB_TRANSPORT = booleanPreferencesKey("show-usb-transport")
+ private const val MAX_FIRMWARE_UPDATE_NOTIFICATION_KEYS = 100
private fun parseDeviceType(name: String): DeviceType? = DeviceType.entries.firstOrNull { it.name == name }
diff --git a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImplTest.kt b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImplTest.kt
index 789756714d..9c2615dade 100644
--- a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImplTest.kt
+++ b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImplTest.kt
@@ -124,4 +124,16 @@ class UiPrefsImplTest {
assertEquals(DeviceType.USB, prefs.selectedConnectionTransport.value)
}
+
+ @Test
+ fun `firmware update notification keys persist without duplicates`() = testScope.runTest {
+ prefs.recordFirmwareUpdateNotificationKey("firmware-update-notified:node:target:2.8.0")
+ prefs.recordFirmwareUpdateNotificationKey("firmware-update-notified:node:target:2.8.0")
+ prefs.recordFirmwareUpdateNotificationKey("firmware-update-notified:node:target:2.9.0")
+
+ assertEquals(
+ setOf("firmware-update-notified:node:target:2.8.0", "firmware-update-notified:node:target:2.9.0"),
+ prefs.firmwareUpdateNotificationKeys.value,
+ )
+ }
}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
index a84cac27d3..371ae7e65a 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
@@ -146,6 +146,12 @@ interface UiPrefs {
fun setSelectedConnectionTransport(type: DeviceType)
+ /** Keys for firmware-update notifications already scheduled on this device. */
+ val firmwareUpdateNotificationKeys: StateFlow<Set<String>>
+
+ /** Records a notification key after the platform notification has been scheduled successfully. */
+ fun recordFirmwareUpdateNotificationKey(key: String)
+
fun shouldProvideNodeLocation(nodeNum: Int): StateFlow<Boolean>
fun setShouldProvideNodeLocation(nodeNum: Int, provide: Boolean)
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FirmwareReleaseRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FirmwareReleaseRepository.kt
index 93baf47ea4..dda6df520d 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FirmwareReleaseRepository.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FirmwareReleaseRepository.kt
@@ -32,6 +32,12 @@ interface FirmwareReleaseRepository {
*/
val nightlyRelease: Flow<FirmwareRelease?>
+ /**
+ * Fetches the authoritative firmware board targets from a release's manifest URL. Returns null when the manifest
+ * cannot be retrieved or parsed, so callers can fail closed rather than offering an incompatible firmware update.
+ */
+ suspend fun getManifestTargets(release: FirmwareRelease): Set<String>?
+
/** Invalidates the local cache of firmware releases. */
suspend fun invalidateCache()
}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NotificationManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NotificationManager.kt
index 5805d57c6c..deed94c8b4 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NotificationManager.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NotificationManager.kt
@@ -24,7 +24,8 @@ package org.meshtastic.core.repository
* [MeshNotificationManager], which composes over this dispatcher.
*/
interface NotificationManager {
- fun dispatch(notification: Notification)
+ /** Returns true only when the platform accepted the notification for delivery. */
+ fun dispatch(notification: Notification): Boolean
fun cancel(id: Int)
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 1a7f3f851e..49fd280ca8 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -630,6 +630,7 @@
<string name="firmware_update_almost_there">Almost there...</string>
<string name="firmware_update_alpha">Alpha</string>
<string name="firmware_update_archive_missing_target">No firmware for %2$s (%3$s) was found in \"%1$s\".</string>
+ <string name="firmware_update_available">Firmware update available</string>
<string name="firmware_update_battery_low">Battery too low (%1$d%). Please charge your device before updating.</string>
<string name="firmware_update_checking">Checking for updates...</string>
<string name="firmware_update_confirm_file_button">Start Flashing</string>
@@ -669,6 +670,10 @@
<string name="firmware_update_no_device">No device connected</string>
<string name="firmware_update_node_info_missing">Node user information is missing.</string>
<string name="firmware_update_not_found_in_release">Could not find firmware for %1$s in release.</string>
+ <string name="firmware_update_notification_android">%1$s is installed. Stable %2$s is available. Open Firmware Updates when you're ready.</string>
+ <string name="firmware_update_notification_flasher">%1$s is installed. Stable %2$s is available. Open Meshtastic Flasher to update this hardware.</string>
+ <string name="firmware_update_open">Open Firmware Updates</string>
+ <string name="firmware_update_open_flasher">Open Meshtastic Flasher</string>
<string name="firmware_update_ota_failed">OTA update failed: %1$s</string>
<string name="firmware_update_ota_unsupported_reason">This device\'s OTA loader rejected the requested update method: %1$s</string>
<string name="firmware_update_rak4631_bootloader_hint">For RAK WisBlock RAK4631, use the vendor\'s serial DFU tool (for example, adafruit-nrfutil dfu serial with the provided bootloader .zip file). Copying the .uf2 file alone will not update the bootloader.</string>
@@ -1829,4 +1834,3 @@
<string name="zh_CN" translatable="false">简体中文</string>
<string name="zh_TW" translatable="false">繁體中文</string>
</resources>
-
diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt
index 09c0e47a7d..db9afbdf2f 100644
--- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/AndroidNotificationManagerTest.kt
@@ -29,6 +29,7 @@ import org.meshtastic.core.repository.Notification
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import kotlin.test.assertEquals
+import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
@@ -85,6 +86,18 @@ class AndroidNotificationManagerTest {
assertEquals(NotificationChannels.NEW_NODES, posted.channelId)
}
+ @Test
+ fun `dispatch reports false when its notification channel is disabled`() {
+ createChannel(NotificationChannels.NEW_NODES, NotificationManager.IMPORTANCE_NONE)
+ val manager = AndroidNotificationManager(context)
+
+ val dispatched =
+ manager.dispatch(Notification(title = "Node", message = "Seen", category = Notification.Category.NodeEvent))
+
+ assertFalse(dispatched)
+ assertEquals(0, shadowOf(systemNotificationManager).allNotifications.size)
+ }
+
@Test
fun `removeLegacyCategoryChannels removes all known legacy category channels`() {
NotificationChannels.LEGACY_CATEGORY_IDS.forEach(::createChannel)
@@ -187,10 +200,8 @@ class AndroidNotificationManagerTest {
assertEquals(expectedChannelId, posted.channelId)
}
- private fun createChannel(id: String) {
- systemNotificationManager.createNotificationChannel(
- NotificationChannel(id, id, NotificationManager.IMPORTANCE_DEFAULT),
- )
+ private fun createChannel(id: String, importance: Int = NotificationManager.IMPORTANCE_DEFAULT) {
+ systemNotificationManager.createNotificationChannel(NotificationChannel(id, id, importance))
}
/**
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt
index 0e102d374f..980fa6d3dc 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt
@@ -23,6 +23,7 @@ import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
import androidx.core.content.getSystemService
import androidx.core.net.toUri
import org.koin.core.annotation.Single
@@ -118,11 +119,13 @@ class AndroidNotificationManager(private val context: Context) : NotificationMan
ChannelConfig(id = NotificationChannels.SERVICE, importance = SystemNotificationManager.IMPORTANCE_MIN)
}
- override fun dispatch(notification: Notification) {
+ override fun dispatch(notification: Notification): Boolean {
ensureChannelsInitialized()
+ val channelId = notification.category.channelConfig().id
+ if (!canPostNotifications(channelId)) return false
val id = notification.id ?: notification.hashCode()
val builder =
- NotificationCompat.Builder(context, notification.category.channelConfig().id)
+ NotificationCompat.Builder(context, channelId)
.setContentTitle(notification.title)
.setContentText(notification.message)
.setSmallIcon(drawable.meshtastic_ic_notification)
@@ -137,9 +140,22 @@ class AndroidNotificationManager(private val context: Context) : NotificationMan
notification.deepLinkUri?.let { uri -> builder.setContentIntent(createDeepLinkPendingIntent(uri, id)) }
- notificationManager.notify(id, builder.build())
+ return try {
+ notificationManager.notify(id, builder.build())
+ true
+ } catch (_: SecurityException) {
+ false
+ }
}
+ private fun canPostNotifications(channelId: String): Boolean =
+ NotificationManagerCompat.from(context).areNotificationsEnabled() &&
+ (
+ Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
+ notificationManager.getNotificationChannel(channelId)?.importance !=
+ SystemNotificationManager.IMPORTANCE_NONE
+ )
+
/**
* Builds a [PendingIntent] that launches [MainActivity] with the given deep-link URI as [Intent.ACTION_VIEW], so
* the existing deep-link plumbing (`UIViewModel.handleDeepLink` → `DeepLinkRouter` → `MultiBackstack`) can
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
index f8695f97de..088859ff15 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
@@ -175,6 +175,12 @@ class FakeUiPrefs : UiPrefs {
selectedConnectionTransport.value = type
}
+ override val firmwareUpdateNotificationKeys = MutableStateFlow<Set<String>>(emptySet())
+
+ override fun recordFirmwareUpdateNotificationKey(key: String) {
+ firmwareUpdateNotificationKeys.value += key
+ }
+
private val nodeLocationEnabled = mutableMapOf<Int, MutableStateFlow<Boolean>>()
override fun shouldProvideNodeLocation(nodeNum: Int): StateFlow<Boolean> =
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt
index 534f6fd00a..2b87584851 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeFirmwareReleaseRepository.kt
@@ -33,16 +33,22 @@ class FakeFirmwareReleaseRepository :
private val _stableRelease = mutableStateFlow<FirmwareRelease?>(null)
private val _alphaRelease = mutableStateFlow<FirmwareRelease?>(null)
private val _nightlyRelease = mutableStateFlow<FirmwareRelease?>(null)
+ private val manifestTargets = mutableMapOf<String, Set<String>?>()
override val stableRelease: Flow<FirmwareRelease?> = _stableRelease
override val alphaRelease: Flow<FirmwareRelease?> = _alphaRelease
override val nightlyRelease: Flow<FirmwareRelease?> = _nightlyRelease
+ override suspend fun getManifestTargets(release: FirmwareRelease): Set<String>? = manifestTargets[release.id]
+
var invalidateCacheCalls: Int = 0
private set
init {
- registerResetAction { invalidateCacheCalls = 0 }
+ registerResetAction {
+ invalidateCacheCalls = 0
+ manifestTargets.clear()
+ }
}
override suspend fun invalidateCache() {
@@ -60,4 +66,8 @@ class FakeFirmwareReleaseRepository :
fun setNightlyRelease(release: FirmwareRelease?) {
_nightlyRelease.value = release
}
+
+ fun setManifestTargets(releaseId: String, targets: Set<String>?) {
+ manifestTargets[releaseId] = targets
+ }
}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt
index 61a1b0e27f..e52dfbc995 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt
@@ -17,20 +17,45 @@
package org.meshtastic.core.ui.viewmodel
import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
import org.koin.core.annotation.KoinViewModel
+import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DeviceHardware
+import org.meshtastic.core.model.FirmwareUpdateNotice
+import org.meshtastic.core.model.FirmwareUpdateNoticePolicy
+import org.meshtastic.core.model.FirmwareUpdateTransport
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.util.TimeConstants
+import org.meshtastic.core.repository.DeviceHardwareRepository
+import org.meshtastic.core.repository.FirmwareReleaseRepository
import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.Notification
+import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.UiPrefs
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.firmware_update_available
+import org.meshtastic.core.resources.firmware_update_notification_android
+import org.meshtastic.core.resources.firmware_update_notification_flasher
+import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
@@ -68,8 +93,14 @@ class ConnectionsViewModel(
serviceRepository: ServiceRepository,
nodeRepository: NodeRepository,
private val uiPrefs: UiPrefs,
+ private val deviceHardwareRepository: DeviceHardwareRepository,
+ private val firmwareReleaseRepository: FirmwareReleaseRepository,
+ private val radioPrefs: RadioPrefs,
+ private val notificationManager: NotificationManager,
) : ViewModel() {
+ private val scheduledFirmwareUpdateNotificationKeys = mutableSetOf<String>()
+
val localConfig: StateFlow<LocalConfig> =
radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig())
@@ -141,4 +172,155 @@ class ConnectionsViewModel(
_hasShownNotPairedWarning.value = true
uiPrefs.setHasShownNotPairedWarning(true)
}
+
+ private val localHardware =
+ combine(nodeRepository.myNodeInfo, nodeRepository.ourNodeInfo) { myNode, ourNode ->
+ val hardwareModel = ourNode?.user?.hw_model?.value ?: return@combine null
+ val target = myNode?.pioEnv?.takeIf { it.isNotBlank() }
+ hardwareModel to target
+ }
+ .flatMapLatest { query ->
+ query?.let { (hardwareModel, target) ->
+ deviceHardwareRepository.observeDeviceHardware(hardwareModel, target)
+ } ?: flowOf(null)
+ }
+
+ private val firmwareUpdateInputs =
+ combine(
+ connectionState,
+ nodeRepository.myId,
+ nodeRepository.myNodeInfo,
+ firmwareReleaseRepository.stableRelease,
+ radioPrefs.devAddr,
+ ) { state, nodeIdentity, myNode, stableRelease, address ->
+ FirmwareUpdateInputs(
+ connectionState = state,
+ nodeIdentity = nodeIdentity,
+ currentVersion = myNode?.firmwareVersion,
+ // A stale (or failed-to-refresh) release catalog must not prompt users. The repository updates this
+ // timestamp only when it writes a current catalog; its bundled seed and a successful refresh are
+ // both valid sources, while an old cache fails closed.
+ stableRelease =
+ stableRelease?.takeIf { it.lastUpdated >= nowMillis - TimeConstants.ONE_HOUR.inWholeMilliseconds },
+ address = address,
+ )
+ }
+
+ private val firmwareUpdateCandidate =
+ combine(firmwareUpdateInputs, localHardware) { inputs, hardware ->
+ val state = inputs.connectionState
+ if (state !is ConnectionState.Connected) return@combine null
+ val transport = inputs.address?.firstOrNull()?.toFirmwareUpdateTransport() ?: return@combine null
+ val stableRelease = inputs.stableRelease ?: return@combine null
+ val deviceHardware = hardware ?: return@combine null
+ FirmwareUpdateCandidate(
+ nodeIdentity = inputs.nodeIdentity,
+ currentVersion = inputs.currentVersion,
+ stableRelease = stableRelease,
+ hardware = deviceHardware,
+ transport = transport,
+ )
+ }
+
+ val firmwareUpdateNotice: StateFlow<FirmwareUpdateNotice?> =
+ firmwareUpdateCandidate
+ .flatMapLatest { candidate ->
+ candidate?.let {
+ flow {
+ val releaseTargets =
+ firmwareReleaseRepository.getManifestTargets(it.stableRelease) ?: emptySet()
+ emit(
+ FirmwareUpdateNoticePolicy.createNotice(
+ nodeIdentity = it.nodeIdentity,
+ currentVersion = it.currentVersion,
+ stableVersion = it.stableRelease.id,
+ hardware = it.hardware,
+ transport = it.transport,
+ releaseTargets = releaseTargets,
+ ),
+ )
+ }
+ .catch { emit(null) }
+ } ?: flowOf(null)
+ }
+ .distinctUntilChanged()
+ .stateInWhileSubscribed(initialValue = null)
+
+ init {
+ firmwareUpdateNotice
+ .map { notice ->
+ notice?.takeIf {
+ FirmwareUpdateNoticePolicy.shouldSchedule(
+ it.notificationKey,
+ uiPrefs.firmwareUpdateNotificationKeys.value,
+ ) && it.notificationKey !in scheduledFirmwareUpdateNotificationKeys
+ }
+ }
+ .filterNotNull()
+ .onEach { notice ->
+ val message =
+ when (notice.destination) {
+ org.meshtastic.core.model.FirmwareUpdateDestination.AndroidUpdate ->
+ getStringSuspend(
+ Res.string.firmware_update_notification_android,
+ notice.currentVersion,
+ notice.stableVersion,
+ )
+
+ org.meshtastic.core.model.FirmwareUpdateDestination.MeshtasticFlasher ->
+ getStringSuspend(
+ Res.string.firmware_update_notification_flasher,
+ notice.currentVersion,
+ notice.stableVersion,
+ )
+ }
+ if (
+ notificationManager.dispatch(
+ Notification(
+ id = notice.notificationKey.hashCode(),
+ title = getStringSuspend(Res.string.firmware_update_available),
+ message = message,
+ type = Notification.Type.Info,
+ category = Notification.Category.NodeEvent,
+ deepLinkUri =
+ if (
+ notice.destination ==
+ org.meshtastic.core.model.FirmwareUpdateDestination.AndroidUpdate
+ ) {
+ "meshtastic:///firmware/update"
+ } else {
+ "https://flasher.meshtastic.org"
+ },
+ ),
+ )
+ ) {
+ scheduledFirmwareUpdateNotificationKeys += notice.notificationKey
+ uiPrefs.recordFirmwareUpdateNotificationKey(notice.notificationKey)
+ }
+ }
+ .launchIn(viewModelScope)
+ }
+}
+
+private data class FirmwareUpdateInputs(
+ val connectionState: ConnectionState,
+ val nodeIdentity: String?,
+ val currentVersion: String?,
+ val stableRelease: FirmwareRelease?,
+ val address: String?,
+)
+
+private data class FirmwareUpdateCandidate(
+ val nodeIdentity: String?,
+ val currentVersion: String?,
+ val stableRelease: FirmwareRelease,
+ val hardware: DeviceHardware,
+ val transport: FirmwareUpdateTransport,
+)
+
+private fun Char.toFirmwareUpdateTransport(): FirmwareUpdateTransport? = when (this) {
+ 'x' -> FirmwareUpdateTransport.Bluetooth
+ 's' -> FirmwareUpdateTransport.Serial
+ 't' -> FirmwareUpdateTransport.Tcp
+ else -> null
}
diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt
index 2c1c121c97..33fd9f88ca 100644
--- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt
+++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt
@@ -20,23 +20,33 @@ import app.cash.turbine.test
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
-import dev.mokkery.matcher.any
import dev.mokkery.mock
-import dev.mokkery.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
+import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DeviceHardware
+import org.meshtastic.core.model.FirmwareUpdateDestination
+import org.meshtastic.core.repository.Notification
+import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
-import org.meshtastic.core.repository.UiPrefs
+import org.meshtastic.core.testing.FakeDeviceHardwareRepository
+import org.meshtastic.core.testing.FakeFirmwareReleaseRepository
import org.meshtastic.core.testing.FakeNodeRepository
+import org.meshtastic.core.testing.FakeRadioPrefs
import org.meshtastic.core.testing.FakeServiceRepository
+import org.meshtastic.core.testing.FakeUiPrefs
+import org.meshtastic.core.testing.TestDataFactory
+import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.LocalConfig
+import org.meshtastic.proto.User
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
@@ -51,14 +61,33 @@ class ConnectionsViewModelTest {
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
private val serviceRepository = FakeServiceRepository()
private val nodeRepository = FakeNodeRepository()
- private val uiPrefs: UiPrefs = mock(MockMode.autofill)
+ private val uiPrefs = FakeUiPrefs()
+ private val deviceHardwareRepository = FakeDeviceHardwareRepository()
+ private val firmwareReleaseRepository = FakeFirmwareReleaseRepository()
+ private val radioPrefs = FakeRadioPrefs()
+ private val dispatchedNotifications = mutableListOf<Notification>()
+ private var notificationsCanBeScheduled = true
+ private val notificationManager =
+ object : NotificationManager {
+ override fun dispatch(notification: Notification): Boolean {
+ if (notificationsCanBeScheduled) dispatchedNotifications += notification
+ return notificationsCanBeScheduled
+ }
+
+ override fun cancel(id: Int) = Unit
+
+ override fun cancelAll() = Unit
+ }
@BeforeTest
fun setUp() {
Dispatchers.setMain(testDispatcher)
+ dispatchedNotifications.clear()
+ notificationsCanBeScheduled = true
every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(LocalConfig())
- every { uiPrefs.hasShownNotPairedWarning } returns MutableStateFlow(false)
+ uiPrefs.hasShownNotPairedWarning.value = false
+ uiPrefs.firmwareUpdateNotificationKeys.value = emptySet()
viewModel =
ConnectionsViewModel(
@@ -66,6 +95,10 @@ class ConnectionsViewModelTest {
serviceRepository = serviceRepository,
nodeRepository = nodeRepository,
uiPrefs = uiPrefs,
+ deviceHardwareRepository = deviceHardwareRepository,
+ firmwareReleaseRepository = firmwareReleaseRepository,
+ radioPrefs = radioPrefs,
+ notificationManager = notificationManager,
)
}
@@ -81,12 +114,10 @@ class ConnectionsViewModelTest {
@Test
fun `suppressNoPairedWarning updates state and prefs`() {
- every { uiPrefs.setHasShownNotPairedWarning(any()) } returns Unit
-
viewModel.suppressNoPairedWarning()
assertEquals(true, viewModel.hasShownNotPairedWarning.value)
- verify { uiPrefs.setHasShownNotPairedWarning(true) }
+ assertEquals(true, uiPrefs.hasShownNotPairedWarning.value)
}
@Test
@@ -139,6 +170,105 @@ class ConnectionsViewModelTest {
}
}
+ @Test
+ fun `connected older known node exposes Android firmware update notice`() = runTest {
+ val hardwareModel = HardwareModel.TBEAM.value
+ val target = "tbeam"
+ deviceHardwareRepository.setHardware(
+ hwModel = hardwareModel,
+ target = target,
+ device = DeviceHardware(architecture = "esp32", platformioTarget = target),
+ )
+ nodeRepository.setMyId("!local")
+ nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo(firmwareVersion = "2.7.0", pioEnv = target))
+ nodeRepository.setOurNode(org.meshtastic.core.model.Node(num = 1, user = User(hw_model = HardwareModel.TBEAM)))
+ radioPrefs.setDevAddr("x:connected")
+ firmwareReleaseRepository.setManifestTargets("v2.8.0", setOf(target))
+ firmwareReleaseRepository.setStableRelease(FirmwareRelease(id = "v2.8.0"))
+ serviceRepository.setConnectionState(ConnectionState.Connected)
+
+ advanceUntilIdle()
+
+ val notice = assertNotNull(viewModel.firmwareUpdateNotice.value)
+ assertEquals("2.7.0", notice.currentVersion)
+ assertEquals("2.8.0", notice.stableVersion)
+ assertEquals(FirmwareUpdateDestination.AndroidUpdate, notice.destination)
+ assertEquals(1, dispatchedNotifications.size)
+ assertEquals(setOf(notice.notificationKey), uiPrefs.firmwareUpdateNotificationKeys.value)
+ assertEquals("Firmware update available", dispatchedNotifications.single().title)
+ assertEquals(Notification.Type.Info, dispatchedNotifications.single().type)
+ assertEquals("meshtastic:///firmware/update", dispatchedNotifications.single().deepLinkUri)
+ }
+
+ @Test
+ fun `does not persist firmware notification dedupe when scheduling is unavailable`() = runTest {
+ val hardwareModel = HardwareModel.TBEAM.value
+ val target = "tbeam"
+ notificationsCanBeScheduled = false
+ deviceHardwareRepository.setHardware(
+ hwModel = hardwareModel,
+ target = target,
+ device = DeviceHardware(architecture = "esp32", platformioTarget = target),
+ )
+ nodeRepository.setMyId("!local")
+ nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo(firmwareVersion = "2.7.0", pioEnv = target))
+ nodeRepository.setOurNode(org.meshtastic.core.model.Node(num = 1, user = User(hw_model = HardwareModel.TBEAM)))
+ radioPrefs.setDevAddr("x:connected")
+ firmwareReleaseRepository.setManifestTargets("v2.8.0", setOf(target))
+ firmwareReleaseRepository.setStableRelease(FirmwareRelease(id = "v2.8.0"))
+ serviceRepository.setConnectionState(ConnectionState.Connected)
+
+ advanceUntilIdle()
+
+ assertNotNull(viewModel.firmwareUpdateNotice.value)
+ assertEquals(emptyList(), dispatchedNotifications)
+ assertEquals(emptySet(), uiPrefs.firmwareUpdateNotificationKeys.value)
+ }
+
+ @Test
+ fun `stale firmware catalog does not expose a notice`() = runTest {
+ val hardwareModel = HardwareModel.TBEAM.value
+ val target = "tbeam"
+ deviceHardwareRepository.setHardware(
+ hwModel = hardwareModel,
+ target = target,
+ device = DeviceHardware(architecture = "esp32", platformioTarget = target),
+ )
+ nodeRepository.setMyId("!local")
+ nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo(firmwareVersion = "2.7.0", pioEnv = target))
+ nodeRepository.setOurNode(org.meshtastic.core.model.Node(num = 1, user = User(hw_model = HardwareModel.TBEAM)))
+ radioPrefs.setDevAddr("x:connected")
+ firmwareReleaseRepository.setStableRelease(FirmwareRelease(id = "v2.8.0", lastUpdated = 0))
+ serviceRepository.setConnectionState(ConnectionState.Connected)
+
+ advanceUntilIdle()
+
+ assertEquals(null, viewModel.firmwareUpdateNotice.value)
+ }
+
+ @Test
+ fun `stable manifest missing the connected target does not expose a notice`() = runTest {
+ val hardwareModel = HardwareModel.TBEAM.value
+ val target = "tbeam"
+ deviceHardwareRepository.setHardware(
+ hwModel = hardwareModel,
+ target = target,
+ device = DeviceHardware(architecture = "esp32", platformioTarget = target),
+ )
+ nodeRepository.setMyId("!local")
+ nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo(firmwareVersion = "2.7.0", pioEnv = target))
+ nodeRepository.setOurNode(org.meshtastic.core.model.Node(num = 1, user = User(hw_model = HardwareModel.TBEAM)))
+ radioPrefs.setDevAddr("x:connected")
+ firmwareReleaseRepository.setManifestTargets("v2.8.0", setOf("t-echo"))
+ firmwareReleaseRepository.setStableRelease(FirmwareRelease(id = "v2.8.0"))
+ serviceRepository.setConnectionState(ConnectionState.Connected)
+
+ advanceUntilIdle()
+
+ assertEquals(null, viewModel.firmwareUpdateNotice.value)
+ assertEquals(emptyList(), dispatchedNotifications)
+ }
+
/**
* Cross-track contract pin: Track A (MeshConnectionManagerImpl.runSiblingHandshakeRecovery) writes the literal
* "Reconnecting…" (with U+2026) to ServiceRepository.connectionProgress. This constant is what Track C compares
diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt
index 9a003e63b3..5a1ad7155b 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt
@@ -62,7 +62,7 @@ class DesktopNotificationManager(
*/
val fallbackNotifications: SharedFlow<ComposeNotification> = _fallbackNotifications.asSharedFlow()
- override fun dispatch(notification: Notification) {
+ override fun dispatch(notification: Notification): Boolean {
val enabled =
when (notification.category) {
Notification.Category.Message -> prefs.messagesEnabled.value
@@ -74,7 +74,7 @@ class DesktopNotificationManager(
}
Logger.d { "DesktopNotificationManager dispatch: category=${notification.category}, enabled=$enabled" }
- if (!enabled) return
+ if (!enabled) return false
scope.launch {
val success = nativeSender.send(notification)
@@ -83,6 +83,7 @@ class DesktopNotificationManager(
emitFallback(notification)
}
}
+ return true
}
private fun emitFallback(notification: Notification) {
diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
index 0a535530a3..c2fa20be72 100644
--- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
+++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt
@@ -24,6 +24,7 @@ import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -31,7 +32,10 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Button
import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -45,6 +49,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleStartEffect
@@ -53,6 +58,8 @@ import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
+import org.meshtastic.core.model.FirmwareUpdateDestination
+import org.meshtastic.core.model.FirmwareUpdateNotice
import org.meshtastic.core.model.InterfaceId
import org.meshtastic.core.navigation.FirmwareRoute
import org.meshtastic.core.navigation.Route
@@ -65,6 +72,11 @@ import org.meshtastic.core.resources.firmware_event_ended_button
import org.meshtastic.core.resources.firmware_recovery_banner
import org.meshtastic.core.resources.firmware_recovery_button
import org.meshtastic.core.resources.firmware_recovery_dismiss
+import org.meshtastic.core.resources.firmware_update_available
+import org.meshtastic.core.resources.firmware_update_notification_android
+import org.meshtastic.core.resources.firmware_update_notification_flasher
+import org.meshtastic.core.resources.firmware_update_open
+import org.meshtastic.core.resources.firmware_update_open_flasher
import org.meshtastic.core.resources.no_device_selected
import org.meshtastic.core.resources.open_bluetooth_settings
import org.meshtastic.core.resources.open_wifi_settings
@@ -79,6 +91,7 @@ import org.meshtastic.core.ui.icon.Bluetooth
import org.meshtastic.core.ui.icon.Language
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.NoDevice
+import org.meshtastic.core.ui.icon.SystemUpdate
import org.meshtastic.core.ui.util.LocalEventBranding
import org.meshtastic.core.ui.util.PermissionStatus
import org.meshtastic.core.ui.util.hasEnded
@@ -127,6 +140,7 @@ fun ConnectionsScreen(
val connectionStatus by connectionsViewModel.connectionStatus.collectAsStateWithLifecycle()
val connectionState by connectionsViewModel.connectionState.collectAsStateWithLifecycle()
val ourNode by connectionsViewModel.ourNodeForDisplay.collectAsStateWithLifecycle()
+ val firmwareUpdateNotice by connectionsViewModel.firmwareUpdateNotice.collectAsStateWithLifecycle()
val regionUnset by connectionsViewModel.regionUnset.collectAsStateWithLifecycle()
val sessionAuthorized by connectionsViewModel.sessionAuthorized.collectAsStateWithLifecycle()
@@ -156,6 +170,7 @@ fun ConnectionsScreen(
val wifiUnavailable = isWifiUnavailable()
val openBluetoothSettings = rememberOpenBluetoothSettings()
val openWifiSettings = rememberOpenWifiSettings()
+ val uriHandler = LocalUriHandler.current
// Auto-start BLE discovery when the screen is visible (lifecycle ≥ STARTED) and the user has previously opted in.
// ScannerViewModel skips screen-entry discovery when a selected device can reconnect through the transport's
@@ -302,6 +317,21 @@ fun ConnectionsScreen(
}
}
+ firmwareUpdateNotice?.let { notice ->
+ FirmwareUpdateNoticeCard(
+ notice = notice,
+ onAction = {
+ when (notice.destination) {
+ FirmwareUpdateDestination.AndroidUpdate ->
+ onConfigNavigate(FirmwareRoute.FirmwareUpdate)
+
+ FirmwareUpdateDestination.MeshtasticFlasher ->
+ uriHandler.openUri("https://flasher.meshtastic.org")
+ }
+ },
+ )
+ }
+
// A device stranded in bootloader mode by an interrupted update can be re-flashed without
// reconnecting first. Shown only while disconnected so the Firmware screen enters its recovery
// path (it uses the live connection when connected); cleared automatically once the device
@@ -463,6 +493,57 @@ fun ConnectionsScreen(
}
}
+/** Informational, non-dismissible nudge for a connected device with a newer stable firmware release. */
+@Composable
+private fun FirmwareUpdateNoticeCard(notice: FirmwareUpdateNotice, onAction: () -> Unit) {
+ val actionLabel =
+ stringResource(
+ when (notice.destination) {
+ FirmwareUpdateDestination.AndroidUpdate -> Res.string.firmware_update_open
+ FirmwareUpdateDestination.MeshtasticFlasher -> Res.string.firmware_update_open_flasher
+ },
+ )
+ val message =
+ stringResource(
+ when (notice.destination) {
+ FirmwareUpdateDestination.AndroidUpdate -> Res.string.firmware_update_notification_android
+ FirmwareUpdateDestination.MeshtasticFlasher -> Res.string.firmware_update_notification_flasher
+ },
+ notice.currentVersion,
+ notice.stableVersion,
+ )
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
+ ) {
+ Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.Top) {
+ Icon(
+ imageVector = MeshtasticIcons.SystemUpdate,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
+ Text(
+ text = stringResource(Res.string.firmware_update_available),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ Text(
+ text = message,
+ modifier = Modifier.padding(top = 4.dp),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Button(modifier = Modifier.padding(top = 12.dp), onClick = onAction) {
+ Icon(imageVector = MeshtasticIcons.SystemUpdate, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(actionLabel)
+ }
+ }
+ }
+ }
+}
+
/** Body for the CONNECTED state — sits inside the shared outer Card in [ConnectionsScreen]. */
@Composable
private fun ConnectedDeviceContent(
Served by rngit 1.5.4 - Generated in 0.16s